Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for creation of associated conversations with message via params #1301

Merged
merged 1 commit into from
Dec 18, 2017

Conversation

begedin
Copy link
Contributor

@begedin begedin commented Dec 15, 2017

What's in this PR?

Update

Still need:

  • tests for DataToAttributes plug
  • tests for Messages.create to check it's possible to create a Conversation alongside
  • tests for controller create action, to serve as integration between DataToAttributes and Messages.create

Original text

This PR progress on and hopefully closes #1293

It's been marked as discussion, but due to requiring the set of features described there for client PR code-corps/code-corps-ember#1597, I was forced to start on some type of implementation.

Will comment on specific changes, but to put it in short

ember-data, by default, does not support including records during post. It has a basic level of support for associating with existing has-many/many-to-many children in the form of the basic {type, id} identifier map, but not a full include, with all the child record's attributes.

ja_serializer does not support it either, so I was forced to provide my own implementation on both ends.

The alternatives are

  • create conversation manually from client, after creating message - To me, this may actually make sense. It makes the API less opinionated and allows potential other clients later on more flexibility on how they implement their own flow. On the downside, there's no clean way to clean up a message without child conversations if the process is interrupted.

  • Let the API infer which child records to create - This makes the API extremely opinionated, forces the client down an inflexible route and creates coupling between the two, so I'm against it. We would have to agree on some non-standard way to provide a list of user ids to create conversations for. I guess, we could define a message has many users through conversations relationship and do what we do with project skills, project categories, etc, but really, which is one way where I could agree to do it that way

  • Can't think of any other approach at the moment

References

Progress on: #1293
Tied to: code-corps/code-corps-ember#1597

|> Changeset.cast(attrs, [:user_id])
|> Changeset.validate_required([:user_id])
|> Changeset.assoc_constraint(:user)
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset is needed to cast_assoc. The message_id is provided automatically by cast_assoc and is not part of the params.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need a test.

|> Changeset.validate_required([:author_id, :project_id])
|> Changeset.assoc_constraint(:author)
|> Changeset.assoc_constraint(:project)
|> Changeset.cast_assoc(:conversations, with: &Messages.Conversations.create_changeset/2)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create requires more work than the default changeset does. We should probably add more tests for the create function to make sure these changes work. I don't think there's an explicit need to extract a create changeset, though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an argument to be made here that this belongs in its own changeset, akin to Messages.Conversations.create_changeset.

Copy link
Contributor

@snewcomer snewcomer Dec 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@begedin this is the nested POST, right? I remember doing this once and I had to create a changeset.

I think what I did was take the nested ids, pass it through |> Enum.map(&Changeset.change/1) and then put_assoc(changeset, :conversations, conversation_changesets) or something like that. But this looks much better if I am understanding it right.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that required the nested ids to already exist in the DB. Not sure what the case is here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snewcomer The ids are not in the database in this case. The child record is created alongside the parent.

@begedin begedin force-pushed the 1293-accomodating-messages-api-to-match-client branch 2 times, most recently from 3d05903 to 6054806 Compare December 15, 2017 14:04
false -> parsed |> Map.put(type, attributes)
end
end)
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The critical part of the included support is here.

I approached this by following the existing doc for this plug, where it's stated that the goal of the plug is to convert JSON API params into a map of params suitable for Ecto.Changeset

From that, the goal was

  • Included belongs_to records are merged with params as a submap property, with the key identical to the belongs to record type
  • Included has_many records are merged with params as a list property, with the key being a pluralized version of the record type

This allows us to simply call cast_assoc(changeset, :record_type, opts) and the casting works out of the box.

I did not work on inferring the pluralized type directly and instead opted to explicitly provide these when calling the plug. It should be sufficient for our needs, but there is a way to infer instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the plug has become quite complex, I think we should add tests for it.

@begedin
Copy link
Contributor Author

begedin commented Dec 15, 2017

I've fixed existing tests.

We still need:

  • tests for DataToAttributes plug
  •  tests for Messages.create to check it's possible to create a Conversation alongside
  • tests for controller create action, to serve as integration between DataToAttributes and Messages.create

|> Changeset.validate_required([:author_id, :project_id])
|> Changeset.assoc_constraint(:author)
|> Changeset.assoc_constraint(:project)
|> Changeset.cast_assoc(:conversations, with: &Messages.Conversations.create_changeset/2)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an argument to be made here that this belongs in its own changeset, akin to Messages.Conversations.create_changeset.

records ++ [record]
end)
false -> parsed |> Map.put(type, attributes)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason to do a case here over an if/else?

|> Changeset.cast(attrs, [:user_id])
|> Changeset.validate_required([:user_id])
|> Changeset.assoc_constraint(:user)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need a test.

Copy link
Contributor

@snewcomer snewcomer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't have the full context of what is going on here. Do the conversations already exist in the DB when posting the message?

|> Changeset.validate_required([:author_id, :project_id])
|> Changeset.assoc_constraint(:author)
|> Changeset.assoc_constraint(:project)
|> Changeset.cast_assoc(:conversations, with: &Messages.Conversations.create_changeset/2)
Copy link
Contributor

@snewcomer snewcomer Dec 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@begedin this is the nested POST, right? I remember doing this once and I had to create a changeset.

I think what I did was take the nested ids, pass it through |> Enum.map(&Changeset.change/1) and then put_assoc(changeset, :conversations, conversation_changesets) or something like that. But this looks much better if I am understanding it right.

|> Changeset.validate_required([:author_id, :project_id])
|> Changeset.assoc_constraint(:author)
|> Changeset.assoc_constraint(:project)
|> Changeset.cast_assoc(:conversations, with: &Messages.Conversations.create_changeset/2)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that required the nested ids to already exist in the DB. Not sure what the case is here.

@joshsmith
Copy link
Contributor

joshsmith commented Dec 15, 2017

@snewcomer the hierarchy is like:

Message
└── Conversation
    └── ConversationPart
    └── ConversationPart

A message could be written with multiple recipients in mind: think of a broadcast message as an example. If we wanted to send an update to all the volunteers, it would be done by creating a message that specifies multiple recipients, all of whom have a conversation created individually for the message.

But a message could also be 1:1, for example individually messaging a user, or a user individually messaging a project. In this case there would only be one conversation.

@snewcomer
Copy link
Contributor

@joshsmith gotcha. On the frontend, the conversation-part is not serialized up though, correct? At one point does the conversation-part get created? Does that happen completely separate from creating a message?

@joshsmith
Copy link
Contributor

@snewcomer yeah, the first conversation part is not the first line of the conversation, which I know is confusing, but necessary due to the 1:n nature of messages. It's similar to a comment thread of a GitHub issue. The initial part of the thread is actually the issue body, while everything else is specifically a comment.

end)
else
parsed |> Map.put(type, attributes)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to fix this fn because Map.update's fun parameter only takes a single value (in this case the accumulated records) and we needed to provide the cast_assoc with an array with the default argument if the key doesn't yet exist (i.e. [attributes]).

I think this is the logic you were looking for @begedin. Spent quite some time debugging and trying to figure this out. More than I'm happy to admit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, the idea is, if there is a many_mapping key, then the included record is for a has_many relationship, so it needs to be put into a list.

If there is no many_mapping key, then the included record is for a belongs_to relationship, so it needs to be a map.

We don't cast a belongs to this way anywhere yet, so it doesn't matter for now, but that was the intended approach. Had I had the chance to add a test yesterday, this would have been clearer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, you did the exact thing that was needed. Sorry, this is difficult to follow

@joshsmith joshsmith changed the title Added support for creation of associated conversations with message via params Add support for creation of associated conversations with message via params Dec 16, 2017
@begedin
Copy link
Contributor Author

begedin commented Dec 16, 2017

Instead of unit testing each individual bit, I opted to integration test on Messages.create/1 and the controller action. The individual bits (changesets) aren't that complex and their behavior is covered with these integration tests.

I think this ought to be good to go for now.

@joshsmith
Copy link
Contributor

This will need tests fixed for the has_many added to conversations.

@@ -43,7 +43,7 @@ config :code_corps, CodeCorps.Repo,
pool_size: 10

# CORS allowed origins
config :code_corps, allowed_origins: ["http://localhost:4200"]
config :code_corps, allowed_origins: ["http://localhost:4200", "chrome-extension://pfdhoblngboilpfeibdedpjgfnlcodoo"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs removed.

@joshsmith
Copy link
Contributor

Will need tests for channels now.

Also, I think we should be propagating new conversation parts to the conversation.updatedAt so we can order conversations by their last updated time rather than their created time. The UI will then prefer the updated time.

@begedin
Copy link
Contributor Author

begedin commented Dec 18, 2017

I'll add the required tests today, but I'd say we're getting to a point where i'd consider the API side mergeable. It does not kill existing features, so there's little reason for holding off and it would make our life easier.

Copy link
Contributor

@joshsmith joshsmith left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some questions here.

end
end
def join(_, _, _) do
{:error, %{reason: "unauthenticated"}}
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the Repo.get returns nil then we return {:error, %{reason: "unauthenticated"}}. I think this changes the behavior we had wanted originally, which is determining whether the user is authenticated or not, hence the pattern match on assigns.

{:ok, %ConversationPart{id: id}} = Messages.add_part(attrs)
assert_broadcast("new:conversation-part", %{id: ^id})
CodeCorpsWeb.Endpoint.unsubscribe("conversation:#{conversation.id}")
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

import Ecto
import Ecto.Changeset
import CodeCorps.Factories
import CodeCorpsWeb.ChannelCase
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason this module is being imported into its own using? I'm guessing it's because it's importing its own namespace into the tests, but is that necessary if the case is used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the general pattern for other XCase modules. Allows us to declare helper methods within this module's namespace, which the test cases can then use.

- Fix DataToAttributes plug's parse_included fn
- Add conversation parts to view and controller
- Add conversation channel
@joshsmith joshsmith force-pushed the 1293-accomodating-messages-api-to-match-client branch from 2c31b58 to 7546651 Compare December 18, 2017 22:52
@joshsmith joshsmith merged commit 8d55213 into develop Dec 18, 2017
@joshsmith joshsmith deleted the 1293-accomodating-messages-api-to-match-client branch December 18, 2017 22:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Discussion: Best way to create Conversations when creating Message
3 participants